Skip to content

[OMNIML-6054] Route load_pretrained by the distribution plan - #2779

Open
shengliangxu wants to merge 138 commits into
shengliangx/hfep-2-plan-decisionfrom
shengliangx/hfep-13b-load-routing
Open

shengliangxu wants to merge 138 commits into
shengliangx/hfep-2-plan-decisionfrom
shengliangx/hfep-13b-load-routing

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Note

The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [6/21]. Rebased onto main 7c3c868 later the same day, after #2739 merged, and onto main f03651e that night.

Base: #2737. The diff below is this PR's alone, and it merges after #2737.

Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.

Series layout

What does this PR do?

Type of change: New feature

load_pretrained (#2733) loads a model whole. A distributed load, such as parallel_load_and_prepare_fsdp2 (main), is a separate entry point, and every caller picks one itself, as hf_ptq.py --use_fsdp2 does. A caller that picks a loader also gets what that loader skips: a sharded load cannot run the model type's loading hook, so a Nemotron-H checkpoint's MTP head is not built and its tensors go to the export unquantized.

This PR makes load_pretrained the one loader, whole or distributed:

  • distribution is a DistributionPlan; by default it is the plan resolve_distribution_plan ([OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737) decides.
  • The plan is recorded on the model and every submodule (set_distribution_plan, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736), where calibration and export read it. load_pretrained records it exactly once, at the end of every load; the loaders it calls record none. fsdp2_wrap records nothing, as on main, so a caller that wraps or loads a model through it directly records the plan itself, as the error for a missing plan says. hf_ptq.py --use_fsdp2 loads through parallel_load_and_prepare_fsdp2 directly and records no plan in this PR; nothing on that path reads the plan before [OMNIML-6071] Calibrate a distributed model on per-rank data #2740, which records it in the same PR that makes get_max_batch_size read it.
  • fsdp2_wrap moves to model_load_utils, with the load that calls it and every other loader, and imports LayerActivationCollector at module level. modelopt.torch.utils.distributed.fsdp2_wrap forwards to it, so callers of the old path keep working, given the hf extra the loaders need, and the FSDP2 helpers it uses stay in distributed.
  • Every load makes a Hub ID local and records the unplaced source keys ([OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733). The FSDP2 load builds its model from the config without the Hub options, so custom code an auto_map names comes with the environment's Hub settings.
  • Every rank loads the same plan. Every rank takes rank 0's distribution, a plan or none, through one broadcast (broadcast_from, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736) before it branches, and loads a plan rank 0 was handed just as it would one it decided. Ranks that each loaded the plan they were handed could build mismatched meshes and hang, and ranks passing different kinds would wait in each other's collectives. Rank 0 checks what it was handed before it sends it, a non-plan included, so a distribution it refuses raises on every rank instead of leaving the others waiting in the broadcast.
  • Refusals, before anything loads.
    • A sharded load cannot run the model type's loading hook, so it refuses a checkpoint the hook would build modules for: the MTP head above.
    • It refuses from_pretrained's placement arguments (device_map, max_memory, ...) and a model class it does not build, rather than ignore them, and an explicit dtype, saying why it does not apply: the load keeps the checkpoint's dtype, and FSDP2's mp_policy sets the dtype it computes in.
    • The FSDP2 load refuses a quantized checkpoint, whose tensors it would read as stored.
    • The FSDP2 load refuses a VLM, which it would build through AutoModelForCausalLM: refused deep in the loader, or its language model alone. It refuses any config _auto_class does not build through AutoModelForCausalLM, before it sets up a group or reads anything, and the refusal says how to load the model whole on every rank: its model class as auto_class, with MODELOPT_FSDP_SIZE=1 or a replicated plan.
    • A plan made for another world size is refused, and so is anything other than a plan.
    • hf_config without the plan it was resolved with is refused. A sharded load builds the model from it as given, so a plan decided here, from the checkpoint's own config, would not be the one it came with. config= is for a config the plan should be decided from. resolve_distribution_plan's docstring says to hand the config it returns to load_pretrained only with a plan that shards the model: a whole-model load reads the checkpoint's config itself and refuses hf_config.

The FSDP2 route's refusal names its ways out (MODELOPT_FSDP_SIZE=1, or a replicated plan), and a config handed with a plan is copied before the load builds from it. With a plan handed and no config, the FSDP2 route reads the checkpoint's config once and hands it to the load, rather than reading it twice. Deciding a plan builds the meta-device model from a copy of the config, so the config returned with the plan, which a sharded load builds from, is the same on every rank: the build writes its dtype and its attention and experts implementations, and only rank 0 builds.

Usage

# torchrun --nproc_per_node 8 quantize.py
from modelopt.torch.utils.distributed import distributed_run
from modelopt.torch.utils.plugins.model_load_utils import load_pretrained

with distributed_run():
    model = load_pretrained("meta-llama/Llama-3.1-8B")  # a whole copy on every rank's GPU
    # With MODELOPT_FSDP_SIZE=world: FSDP2-sharded over the 8 ranks instead.

Testing

  • New tests/unit/torch/utils/test_load_pretrained_distribution.py. It runs on CPU, and stand-in loaders record their calls. It covers:
    • each load: the arguments it gets and the plan it records;
    • a decided FSDP2 load, handed the config the plan was decided from instead of reading it again, and a handed FSDP2 plan's load reading the checkpoint's config once (that check fails without its fix);
    • a single process's FSDP2 load setting up its group of one, and leaving none behind when the load is refused;
    • every refusal, raised before any loader runs, the expert-parallel plan's included; test_a_load_refuses_what_it_cannot_load has a VLM row (a Llava config), which fails without the refusal;
    • on two real gloo ranks (cpu_dist_workers), rank 0 handed a replicated plan and rank 1 an FSDP2 one, or none: both load whole and record rank 0's plan. A rank that loads the plan it was handed fails at once, on the placement refusal. Rank 0 handed a non-plan fails both ranks;
    • a caller's own model class: loaded whole on every rank, planned without building the model, with no sharding knob, and refused by the sharded load with one.
  • tests/unit/torch/utils/test_resolve_distribution_plan.py: the config returned with the plan is the one every rank read, unchanged by rank 0's meta-device build (new test_the_config_returned_with_the_plan_is_as_every_rank_read_it, which fails without its fix).
  • A single process's load, placed as from_pretrained places it, with the caller's arguments and a single process's plan: [OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733's real CPU load in tests/unit/torch/utils/test_unplaced_source_keys.py, which now also checks the plan.
  • fsdp2_wrap leaving the plan to the load: a CPU unit test in tests/unit/torch/utils/test_load_pretrained_distribution.py. [OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733's GPU tests that wrap or load through it directly (test_export_carry_over.py, test_model_load_utils.py) record the plan themselves. Its old path forwarding every argument: tests/unit/torch/utils/test_distributed_run.py.
  • Mutation-checked: 17 mutants, each failing a test. They drop the rank-GPU default, pin a single process's model, drop the config handoff or the world-size check, load an expert-parallel or FSDP2 plan whole, accept a string, let each rank load the plan it was handed, record no plan in the load or record one in fsdp2_wrap too, set no group up for a single process's FSDP2 load, set one up over a live group, or have the old fsdp2_wrap path drop an argument or the result.
  • New tests/gpu/torch/utils/test_load_pretrained_distribution.py runs load_pretrained left to decide, on every GPU of the node:
    • a dense model loads whole on each rank, and with MODELOPT_FSDP_SIZE=world FSDP2-sharded;
    • each rank's weights match the checkpoint;
    • the load records its unplaced keys and its plan.
    • Not run locally; CI's GPU workflow runs it.
  • tests/examples/hf_ptq/test_example_utils.py: get_model's stand-in model is a module now, since load_pretrained records its plan on it.
  • The load refusals are one parametrized test, and the single-process load test gives way to [OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733's real load. Each drop is checked by mutation.
  • A single process without a launcher is refused an FSDP2 load, without stubbing the setup, and a handed config is left as the caller passed it. A rank of a multi-rank launch whose process group is not set up is refused any load, before anything is fetched. The refusal table has an explicit dtype.
  • Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full tests/unit on transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.
  • Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.
  • Review fixes, 2026-10-27: a rank of a multi-rank launch without its process group is refused on every route, before anything is fetched; a caller's model class with a knob asking to shard is refused before the model is built; a changelog entry for fsdp2_wrap's move. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.
  • Rebased on 2026-10-27 onto main 7c3c868, after [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739 merged: pre-commit, mypy included, and this PR's own unit tests pass at this head, and the series' PRs merged together add no unit-test failure over main (tests/unit/torch utils, export, quantization and models, on transformers 5.18 and 5.5.4 and on torch 2.8). [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739's FSDPModule import, now on main, and this PR's CPUOffloadPolicy, fully_shard are one import line, and fsdp2_wrap sits ahead of the meta-DCP loader, as before.
  • Review fixes, 2026-10-27 (Claude's review after the rebase): a sharded load takes dtype=None and torch_dtype as from_pretrained does. None and "auto" keep the checkpoint's dtype under either name, and any other dtype is refused with the dtype message, where torch_dtype got the placement one. The new cases fail without the change.
  • Review suggestions, 2026-10-27 evening: a launch whose processes set no group up (hf_ptq under torchrun without --use_fsdp2) loads the whole model on each process again, refusing there only a plan for several ranks and a sharded load; a handed plan is checked before the checkpoint is fetched; an FSDP2 layout decided from rank 0's GPU memory says so when it refuses placement options; fsdp2_wrap imports the layer detection where it uses it.
  • Review fixes, 2026-10-27 night: a launch without its process group refuses MODELOPT_EP_DEGREE above 1 or MODELOPT_FSDP_SIZE naming the world before anything is fetched, and the sharded-load refusal names the plan to pass. The refusal test's new rows fail without the change.
  • Review suggestions, 2026-10-27, last round: on a launch without its group, a MODELOPT_EP_DEGREE that is not an EP degree is refused with the plan decision's message, and a padded value is read as its number; load_pretrained's docstring says the automatic layout weighs the model in the checkpoint's own dtype. The refusal test's new row fails without the change.
  • Review fix, 2026-10-27, last round: a one-rank sharded plan in a launch without its group is refused before the checkpoint is fetched, and the advice for a model with no plan names load_pretrained again, which records the plan from this PR on.
  • Review fixes, 2026-10-27, last round: hf_config with a plan that loads the model whole is refused before the checkpoint is fetched, with the no-plan case's message, and the new pre-fetch test row fails without the change; fsdp2_wrap's docstring says the FSDP2 load wraps with it.
  • Review suggestions, 2026-10-28: the refusals that depend only on a handed plan's arguments (auto_class, dtype, options the sharded load does not take) run before the checkpoint is fetched, and the new test row fails without the change; a test docstring no longer says fsdp2_wrap loads.
  • Review suggestions, 2026-10-28, fifth round: the handed-plan test pins the auto_class and dtype arms of the pre-fetch refusal, each failing with its arm left out of the pre-fetch call; the four function-local imports of modelopt.torch.utils.distributed go, now that [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737 imports it at module top.
  • Declined review suggestion, 2026-10-28, fifth round: one helper for the kwargs view the pre-fetch refusal and _load_sharded read; the two agree, and the handed-plan test pins each pre-fetch arm.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ load_pretrained keeps its behavior in a single process unless the plan shards the model (FSDP2 there needs torchrun --nproc_per_node=1), and distribution is new. fsdp2_wrap behaves as on main and records no plan; its old import path still works, given the hf extra.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅ 0.49.0, Misc.
  • Did you get Claude approval on this PR?: ❌ Not yet.

Additional Information

Part 6 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2737. The expert-parallel half of this routing is #2780.

Replaces #2774, which carried this routing whole as [19/20]. Moving its branch down the stack made the branch part of #2774's old base, so GitHub closed #2774 as merged into that base; nothing reached main.

Summary by CodeRabbit

  • New Features

    • Added GGML imatrix calibration with weighted search and per-expert statistics for fused MoE models.
    • Updated IQ recipes for combined GPTQ and imatrix calibration, and added a Nemotron 3.5 Super VL AutoQuantize recipe.
    • Added standalone draft model training and export for external-draft speculative decoding.
    • Distributed loading supports replicated or FSDP2-sharded models through configurable distribution plans, which are recorded on loaded models. Automatic plan selection and Hub authentication and caching options are supported.
  • Documentation

    • Added guidance for distributed loading and distribution-plan recording.

@copy-pr-bot

copy-pr-bot Bot commented Oct 13, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 6150cee and fb8bf38.

📒 Files selected for processing: 4
  • CHANGELOG.rst
  • modelopt/torch/utils/plugins/model_load_utils.py
  • tests/unit/torch/utils/test_load_pretrained_distribution.py
  • tests/unit/torch/utils/test_unplaced_source_keys.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.

Walkthrough

load_pretrained now accepts or resolves a distribution plan and selects whole-model or FSDP2-sharded loading. It validates inputs, records load metadata, and provides FSDP2 wrapping through the model-loading plugin.

Changes: Distributed model loading

Layer / File(s) Summary
Plan-based load routing
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_load_pretrained_distribution.py, tests/gpu/torch/utils/test_load_pretrained_distribution.py, tests/examples/hf_ptq/test_example_utils.py, CHANGELOG.rst, tests/unit/torch/utils/test_unplaced_source_keys.py
load_pretrained resolves or accepts a distribution plan and selects whole-model or FSDP2-sharded loading. Sharded loads validate supported arguments and checkpoints. The loader records the plan and unplaced keys. Unit and GPU tests cover routing, validation, and placement.
FSDP2 wrapping and delegation
modelopt/torch/utils/plugins/model_load_utils.py, modelopt/torch/utils/distributed.py, tests/unit/torch/utils/test_distributed_run.py, tests/unit/torch/utils/test_load_pretrained_distribution.py
The plugin implements FSDP2 wrapping, and distributed.py delegates to it. Tests check argument forwarding and confirm that wrapping alone does not record a distribution plan.
Distribution metadata in distributed tests
tests/gpu/torch/export/test_export_carry_over.py, tests/gpu/torch/utils/test_model_load_utils.py
Distributed test models record an FSDP distribution plan before export-related checks.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant load_pretrained
  participant _route
  participant _load_sharded
  participant parallel_load_and_prepare_fsdp2
  participant from_pretrained
  Caller->>load_pretrained: Provide checkpoint and optional distribution plan
  load_pretrained->>_route: Validate plan and select load path
  alt Dense FSDP plan
    load_pretrained->>_load_sharded: Load checkpoint with config
    _load_sharded->>parallel_load_and_prepare_fsdp2: Load on local CUDA device
  else Whole-model plan
    load_pretrained->>from_pretrained: Load model with device map
  end
  load_pretrained-->>Caller: Return model with plan and unplaced keys recorded
Loading

Suggested reviewers: kevalmorabia97, sugunav14

Merge Risk: Minimal · up to fb8bf

The documented distributed loading paths are ready for normal checks. Users selecting an unsupported expert-parallel plan must choose a supported loading option.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed The only linked issue, #2774, is closed and completed. The issue states it is historical context only. No active directly linked target remains, so no linked-issue coding requirements apply to this pu…
Out of Scope Changes check Passed The whole-PR summary (mergeBase 4e10244 to currentHead fb8bf38) shows changes that match the PR description. In modelopt/torch/utils/plugins/model_load_utils.py, load_pretrained gains a distribution…
Security Anti-Patterns Passed No listed security anti-pattern is introduced by the pull request. In the changed modelopt Python files, torch.load uses weights_only=True; no added weights_only=False, allow_pickle=True, ha…
Title check Passed The title clearly and concisely describes the main change: routing load_pretrained according to the distribution plan.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Linked Issues check

Explanation

The only linked issue, #2774, is closed and completed. The issue states it is historical context only. No active directly linked target remains, so no linked-issue coding requirements apply to this pull request. The prior PASS result still holds.

Full details: Out of Scope Changes check

Explanation

The whole-PR summary (mergeBase 4e10244 to currentHead fb8bf38) shows changes that match the PR description. In modelopt/torch/utils/plugins/model_load_utils.py, load_pretrained gains a distribution parameter, resolve_distribution_plan gains config and Hub access options, and fsdp2_wrap moves here. In modelopt/torch/utils/distributed.py, fsdp2_wrap forwards to the new location and its signature is unchanged. The Hub access options support the PR's Hub ID localization. The test changes cover the same functions: test_example_utils.py (FakeModel now a Module so the plan can be recorded), test_export_carry_over.py and test_model_load_utils.py (manual plan recording after fsdp2_wrap), test_distributed_run.py (forwarding), test_unplaced_source_keys.py (plan check), test_resolve_distribution_plan.py (Hub options), and the new unit and GPU test modules. The CHANGELOG.rst entry documents the same behavior. No file in the summary is shown to be unrelated. The assessment rests on the AI-generated summary; the source hunks were not read in this review.

Full details: Security Anti-Patterns

Explanation

No listed security anti-pattern is introduced by the pull request. In the changed modelopt Python files, torch.load uses weights_only=True; no added weights_only=False, allow_pickle=True, hardcoded trust_remote_code=True, eval()/exec() call, or # nosec comment exists. The pull request changes no pyproject.toml or requirements*.txt file, so it adds no dependency requiring license review.

✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.49550% with 5 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (shengliangx/hfep-2-plan-decision@ae355c3). Learn more about missing BASE report.

Files with missing lines Patch % Lines
modelopt/torch/utils/plugins/model_load_utils.py 95.37% 5 Missing ⚠️
Additional details and impacted files
@@                         Coverage Diff                         @@
##             shengliangx/hfep-2-plan-decision    #2779   +/-   ##
===================================================================
  Coverage                                    ?   61.57%           
===================================================================
  Files                                       ?      649           
  Lines                                       ?    72388           
  Branches                                    ?        0           
===================================================================
  Hits                                        ?    44571           
  Misses                                      ?    27817           
  Partials                                    ?        0           
Flag Coverage Δ
unit 61.57% <95.49%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 3ac7c7b

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13b-load-routing branch from 3ac7c7b to df76b36 Compare October 14, 2026 00:14
@shengliangxu
shengliangxu added this pull request to stack #2786 October 14, 2026 00:14
@shengliangxu shengliangxu changed the title [OMNIML-6054] [3/21] Route load_pretrained by the distribution plan [OMNIML-6054] [4/22] Route load_pretrained by the distribution plan Oct 14, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test df76b36

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13b-load-routing branch from df76b36 to aa3615e Compare October 14, 2026 01:47
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2779/

Built to branch gh-pages at 2026-10-27 00:05 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test aa3615e

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from 00417b9 to ff7f381 Compare October 14, 2026 16:15
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13b-load-routing branch 3 times, most recently from aa3615e to 168c90d Compare October 14, 2026 16:16
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 168c90d

@shengliangxu
shengliangxu removed this pull request from stack #2786 October 14, 2026 18:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from ff7f381 to f61d317 Compare October 14, 2026 18:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13b-load-routing branch from 168c90d to 537ff8d Compare October 14, 2026 18:13
@shengliangxu
shengliangxu added this pull request to stack #2802 October 14, 2026 18:14
@shengliangxu shengliangxu changed the title [OMNIML-6054] [4/22] Route load_pretrained by the distribution plan [OMNIML-6054] [3/21] Route load_pretrained by the distribution plan Oct 14, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 537ff8d

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from f61d317 to a179de9 Compare October 14, 2026 21:19
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13b-load-routing branch 3 times, most recently from 465f1eb to aa2a065 Compare October 14, 2026 21:46
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test aa2a065

Without its group every collective runs on that rank alone, so each rank of the launch planned
and loaded the model as a single process's, the whole model on every rank, and recorded a one-rank
plan on a model the whole launch holds. load_pretrained now refuses it first, on every route; the
two route-specific hints it makes unreachable go.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…n needs

With a knob asking to shard, the decision built the model on the meta device through its own pick
of class, which fails for a model the caller loads through another class (an encoder-decoder
through AutoModel), before the sharded load could refuse the class clearly. Every sharded load
refuses it, so the decision does, before building.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Calling it through its old path now needs the hf extra, as the loaders do.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…does

from_pretrained reads dtype=None as "auto", and torch_dtype as the older
name of dtype. The sharded load refused dtype=None, which keeps the
checkpoint's dtype as "auto" does, and gave torch_dtype, which
hf_ptq's example_utils sets for DeciLM checkpoints, the placement
refusal instead of the dtype one. It now checks both names: None and
"auto" keep the checkpoint's dtype, any other value is refused for
the dtype it asks for.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Importing it at module level loaded the whole quantization package,
which registers every mode and plugin, into every import of the
loaders. Only a wrap needs it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Making the checkpoint local can mean a download of the whole model,
and a plan the load refuses failed only after it. Rank 0 now checks
and broadcasts the plan first, and the config arguments are checked
before the fetch too.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The launch guard refused every route whenever WORLD_SIZE named
several ranks and no process group was set up, so hf_ptq launched
with torchrun but without --use_fsdp2, where nothing sets a group up,
failed where it used to load. Each such process now loads as a single
process does, the whole model with no plan to agree on, as before plans
existed. Only what needs the group is refused there: a plan for several
ranks, before anything is fetched, and a sharded load, which would
otherwise set up a group of all the launch's ranks for a one-rank plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
With neither a plan nor MODELOPT_FSDP_SIZE, rank 0's GPU memory decides
whether the dense part is sharded, and the sharded load then refuses
device_map, quantization_config or a dtype as if the caller had asked
for it, so the same call loads on one GPU and fails on a smaller one
with no word of why. The refusal now says the layout was decided from
rank 0's memory and how to keep the load whole, and the docstring says
which calls depend on it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every other refusal of the sharded load names the route it is for; these
two named FSDP2 literally.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A launch whose processes set no process group up has each process load
the whole model, but MODELOPT_FSDP_SIZE naming the world or an
MODELOPT_EP_DEGREE above 1 asks to shard it over the launch's ranks:
loading whole there ignored the knob without a word, eight whole copies
where it asked for one sharded. Such knobs are now refused before
anything is fetched, with the way to set the group up; the default,
auto, keeps the whole load.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The one-rank FSDP2 plan that reaches it can never load on a launch of
several ranks, so loading inside distributed_run() as advised only met
a second refusal for the plan's world size. The refusal now names the
plan for the launch.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The launch-without-group check matched MODELOPT_EP_DEGREE with str.isdigit(), so a value that
is not a degree ("eigth") was dropped there, while the plan decision refuses it with a
ValueError once a group exists. It now reads the knob through _ep_degree, as the decision does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
With no plan, the dense layout is decided from the meta model built in the checkpoint's own
dtype, not one the load asks for, so a load that widens it should be handed its plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
#2746 makes _ep_degree resolve an automatic degree from the model's spec, which this check runs
before: called here, its later signature would leave the check without the model type it needs.
The check now parses the knob itself, with the plan decision's spellings and message, so a value
that is not a degree ("eigth", "-1") is refused and a padded one (" 4") counts as the degree it is.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A one-rank plan for a sharded load, handed in a multi-rank launch whose group is not set up, was
refused in _load_sharded, after the checkpoint was made local and its config read: under torchrun
with a Hub ID, the whole snapshot downloaded first. The refusal now sits with the other groupless
checks, which run before anything is fetched; the test checks that nothing is.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…cords one

load_pretrained records the plan it loads by from this PR on, so the advice for a model with no
plan names it beside set_distribution_plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ched

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…wrap with it

The test's docstring still said fsdp2_wrap loads a model, the claim the
function's own docstring dropped: it only shards the decoder layers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…kpoint is fetched

The arguments alone decide whether the FSDP2 load refuses auto_class, a dtype
or an option that places a whole model, but those refusals ran inside the load,
after ensure_local_checkpoint had downloaded the checkpoint. With the plan handed
in, the load is known up front, so the same checks now run before the fetch, as
hf_config's does; a decided plan still meets them in the load.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The module imports modelopt.torch.utils.distributed at the top now, so the
four function-local imports of it bound the same module again.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The handed-plan test pinned only the unknown-option arm of the pre-fetch
refusal. The auto_class and dtype rows each fail with their arm left out of
the pre-fetch call, where the refusal would come only after the download.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 2044668

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +692 to +704
if plan is not None and _route(plan) is not None:
# A handed plan names its load before anything is fetched, and the arguments alone decide
# what that load refuses. A config passed for it becomes its hf_config; subfolder is where
# the checkpoint is.
_refuse_sharded_load_options(
_route(plan),
auto_class,
{
k: v
for k, v in kwargs.items()
if k != "subfolder" and not (k == "config" and config is not None)
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The pre-fetch refusal and the one inside _load_sharded now have to agree about the same kwargs, but each gets there differently: here two keys are filtered out inline (subfolder, and config when it is a PretrainedConfig), while _load_sharded relies on subfolder having been popped at L706 and config deleted at L738. Nothing links the two, so a keyword popped between L704 and L755 later on would silently widen what the pre-fetch call refuses relative to the real load — exactly the kind of drift the two-call structure exists to prevent.

subfolder at least can stop being special-cased: its pop has no dependency on ckpt_path, only the os.path.join does. Hoisting the pop above this block removes one of the two filter clauses:

    subfolder = kwargs.pop("subfolder", None)
    if plan is not None and _route(plan) is not None:
        # A handed plan names its load before anything is fetched, and the arguments alone decide
        # what that load refuses. A config passed for it becomes its hf_config.
        _refuse_sharded_load_options(
            _route(plan),
            auto_class,
            {k: v for k, v in kwargs.items() if not (k == "config" and config is not None)},
        )
    _, ckpt_path = ensure_local_checkpoint(ckpt_path, **revision, **hub_options)
    if subfolder:
        ckpt_path = os.path.join(ckpt_path, subfolder)

Not a behavior change — just one fewer invariant a reader (or a later patch) has to hold.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept. It changes no behavior, and the drift it guards against is now pinned: the handed-plan test has a row for each arm of the pre-fetch refusal (auto_class, dtype, unknown options), and each row fails if the pre-fetch view stops matching the load's.

Comment on lines +646 to +654
if alone and distribution is None:
# The knobs ask to shard over the launch, rather than leave each process the whole model. A
# value that is not an EP degree is refused, as the plan decision refuses it.
ep, world = env.ep_degree().strip(), int(os.environ["WORLD_SIZE"])
if ep.lower() not in ("auto", "0") and not ep.isdigit():
raise ValueError(f"MODELOPT_EP_DEGREE={ep!r}: expected auto, 0 (auto) or an EP degree.")
asked = [f"MODELOPT_EP_DEGREE={ep}"] if ep.isdigit() and int(ep) > 1 else []
if _dense_from_env(world) == "fsdp":
asked.append(f"MODELOPT_FSDP_SIZE={env.fsdp_size()}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The comment says a non-EP-degree value "is refused, as the plan decision refuses it", but this parse is stricter than _ep_degree's, so the two disagree on signed values. _ep_degree(None) does int(value), which accepts "+2" → 2; here "+2" fails both ep.lower() not in ("auto", "0") and ep.isdigit(), so MODELOPT_EP_DEGREE=+2 raises "expected auto, 0 (auto) or an EP degree" on a launch without its group while being read as a plain 2 inside distributed_run(). ("-1" is refused on both paths, just with different messages — _ep_degree passes it through and DistributionPlan.__post_init__ rejects it.)

Reusing the one definition keeps the claim true and drops the duplicated parse:

    if alone and distribution is None:
        # The knobs ask to shard over the launch, rather than leave each process the whole model. A
        # value that is not an EP degree is refused, as the plan decision refuses it.
        ep, world = _ep_degree(None), int(os.environ["WORLD_SIZE"])
        asked = [f"MODELOPT_EP_DEGREE={env.ep_degree()}"] if ep != "auto" and ep > 1 else []
        if _dense_from_env(world) == "fsdp":
            asked.append(f"MODELOPT_FSDP_SIZE={env.fsdp_size()}")

_ep_degree(None) already raises with the MODELOPT_EP_DEGREE= prefix, so the explicit ValueError above goes away too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept. The two parses accept the same values anyone sets (auto, 0, a positive degree) and refuse the same malformed ones; they differ only on a signed +2, which int() takes. Sharing _ep_degree here would tie this pre-group check to a helper whose signature #2746 extends with the model type, for no value a user writes.

Comment on lines +53 to +54
_move_to_fsdp_device,
_off_dtype_params,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] With fsdp2_wrap moved here, _off_dtype_params and _move_to_fsdp_device have no remaining caller in distributed.py — the wrap was their only one, and nothing else in modelopt/ references them (only tests/unit/torch/utils/test_distributed.py imports _off_dtype_params directly). So this is now a module reaching across package boundaries for two underscore-private names, which the PR description frames as "the FSDP2 helpers it uses stay in distributed" — true, but they are no longer shared.

Either moving them down here beside their caller (and repointing the unit test), or leaving them but dropping the "shared" framing, would make the ownership unambiguous. Non-blocking — and if the expert-parallel loader in the later PRs grows a second caller inside distributed.py, leaving them is the right call; worth a one-line comment saying so if that is the plan.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept in distributed.py, with the FSDP2 helpers they belong to: _off_dtype_params encodes the dtype rule fully_shard imposes, and _move_to_fsdp_device the device it shards from. #2742's dense sharding reuses _off_dtype_params for its own fully_shard calls. The description says they stay there, not that more than one module calls them.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review — [6/21] Route load_pretrained by the distribution plan

Scope. Bare /claude review, so a full review. 11 changed files (+1191/-80) against origin/shengliangx/hfep-2-plan-decision. Reviewed both modelopt/ files and CHANGELOG.rst in full, plus the base-branch pieces this routing leans on (DistributionPlan, set_distribution_plan/distribution_plan, broadcast_from/all_reduce/_refuse_a_launch_without_its_group, setup/size/local_rank/is_distributed_launch, env.ep_degree/env.fsdp_size, _ep_degree/_dense_from_env/_decide_distribution/resolve_distribution_plan, record_unplaced_source_keys, _auto_class, parallel_load_and_prepare_fsdp2) and the new/changed tests. Seven prior Claude rounds were read first and are deduped below. CodeRabbit has its review paused on this branch. I could not run the suite here, so coverage claims are read from the tests.

Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3. All three are new this round.

Resolved since the last round

  • The handed-plan refusals that need only the arguments now run before the fetch. _refuse_sharded_load_options is a standalone predicate called at L696, before ensure_local_checkpoint, and again inside _load_sharded: a handed FSDP2 plan with an auto_class the sharded load cannot build, a non-auto dtype/torch_dtype, or a placement keyword now raises without downloading a Hub repo. test_a_handed_plan_is_checked_before_the_checkpoint_is_fetched has rows for the auto_class and dtype arms with ensure_local_checkpoint monkeypatched to pytest.fail, so each fails if dropped from the pre-fetch call. Closes last round SUGGESTION 1.
  • fsdp2_wrap imports LayerActivationCollector where it uses it, so model_load_utils no longer drags all of modelopt.torch.quantization into every importer. Closes last round SUGGESTION 3, and the old cycle does not re-form: layerwise_calib imports modelopt.torch.utils.distributed, not this module, and distributed.fsdp2_wrap back-references it function-locally.
  • The four function-local distributed imports are gone, and distributed.py has no dead imports left (CPUOffloadPolicy/fully_shard dropped and unreferenced; FSDPModule and Any still used).

What I re-checked and found correct

  • Collective symmetry. alone is env+group-only, so every process agrees on it; the alone short-circuit never lands one process in broadcast_froms own launch guard while another skips it. Rank 0 runs _route inside handed() before sending and broadcast_from ships a sender error as text, so a refused plan raises everywhere; peers re-run _route on the received plan against the same dist.size(). resolve_distribution_plans own collectives run only in the arm every rank takes together.
  • The new pre-fetch refusal is rank-invariant (broadcast plan, auto_class, kwargs) and precedes every collective in _load_sharded.
  • The two refusal sites agree. The pre-fetch dict differs from _load_shardeds only by the keys popped/deleted in between (subfolder at L706, config at L738); hub_options/revision are popped above L692 so neither call sees them. A config that is not a PretrainedConfig stays in both dicts and is refused by both. _SHARDED_LOAD_OPTIONS[_FSDP2] still matches the loader signature for all six forwarded keywords.
  • hf_config handoff, all four combinations of {handed, decided} x {config=, not}: handed+config deep-copies into hf_config with the del gated on the same isinstance read (no KeyError); handed+hf_config passes through; handed+neither reads the config once at L745; decided+FSDP2 takes resolve per-rank copy via setdefault, which cannot collide because L680 refuses hf_config for a decided or replicated plan. model_type resolves with no second read in any path. _decide_distributions deepcopy before build_meta_causal_lm keeps rank 0 build writes out of the config a sharded load builds from.
  • Launch-without-its-group guard. The three alone arms separate a multi-rank plan, a one-rank sharded plan, and the env knobs asking to shard; WORLD_SIZE is only read under is_distributed_launch(); _dense_from_env(int(WORLD_SIZE)) rightly uses the launch world rather than dist.size() (1 without a group). All three precede ensure_local_checkpoint, which the test asserts.
  • Refusal ordering in _load_sharded all precedes dist.setup(), so a refused single-process load leaves no group behind; _auto_class is pure so using it as a predicate is safe.
  • Plan recorded exactly once; fsdp2_wrap and parallel_load_and_prepare_fsdp2 record none, so the single set_distribution_plan cannot trip its own guard on either route.
  • Unplaced-key record round-trips on the sharded route, and _resolve_checkpoint_dir short-circuits on os.path.isdir so _modelopt_source_checkpoint is not clobbered with a Hub ID.
  • Backward compatibility. _decide_distribution returns at world_size == 1 ignoring both knobs, the device_map injection is setdefault gated on plan.world_size > 1, and the alone path restores the pre-plan behaviour for a launcher that only exports WORLD_SIZE. Both changelog entries are user-facing and correctly filed.
  • No prompt-injection text anywhere in the diff.

SUGGESTIONs (non-blocking, all inline)

  1. The two refusal sites kwargs filters must stay in sync by hand — hoisting the subfolder pop above the pre-fetch call removes one of the two inline filter clauses, with no behavior change.
  2. The alone guard MODELOPT_EP_DEGREE parse is stricter than _ep_degrees: +2 is read as 2 inside distributed_run() but raises here, so the comment claim "as the plan decision refuses it" is not quite true. _ep_degree(None) gives one definition.
  3. _off_dtype_params/_move_to_fsdp_device have no caller left in distributed.py — two underscore-private names now imported across package boundaries; either move them with their caller or drop the "shared helpers" framing.

Previously raised, still open — not re-raised

  • checkpoint_has_mtp stands in for "this model type has a loading hook"; what the sharded route skips is any plugin prepare_for_loading, which nemotron_h only coincides with today.
  • The double record_unplaced_source_keys on the sharded route (the second write redefines _modelopt_source_checkpoint from resolved_path to ckpt_path).
  • _route(plan) is evaluated several times in a row at L680-L724, and hf_config or config at L749 picks a config by truthiness of a third-party object.
  • examples/hf_ptq/hf_ptq.py --use_fsdp2 records no plan; inert until the calibration PR reads one.

Risk: low. ~340 source lines, and the parts that would hang a job or corrupt an export — collective symmetry and count, rank 0 checking before it sends, record-once, the config handoff, the launch guard, the keyword surface — hold up under tracing and are well covered; the new pre-fetch arms each have a row that fails without them. This round two deltas (pre-fetch refusal, import cleanup) are both strict improvements, and nothing I found rises above a suggestion.

Approving. The three SUGGESTIONs are yours to take or leave.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants